Skip to content

fix(scripts): scale token amounts without a floating-point intermediary#194

Open
Nexory wants to merge 1 commit into
base:mainfrom
Nexory:fix/scripts-amount-float-precision
Open

fix(scripts): scale token amounts without a floating-point intermediary#194
Nexory wants to merge 1 commit into
base:mainfrom
Nexory:fix/scripts-amount-float-precision

Conversation

@Nexory

@Nexory Nexory commented Jun 9, 2026

Copy link
Copy Markdown

What

The Solana bridge and SPL mint CLI handlers scale the user-entered amount with a floating-point intermediary:

const scaledAmount = BigInt(Math.floor(args.amount * Math.pow(10, decimals)));

args.amount is a parseFloat-ed number (from the Zod .transform((val) => parseFloat(val))), and amount * 10 ** decimals is evaluated in IEEE-754 double precision. That loses accuracy for ordinary decimal inputs.

Why it is wrong

1.005   SOL  (9 decimals):  Math.floor(1.005 * 1e9)  = 1_004_999_999   (want 1_005_000_000, off by 1 lamport)
1.000001 USDC (6 decimals): Math.floor(1.000001 * 1e6) = 1_000_000      (want 1_000_001, trailing unit dropped)
1.005   ETH  (18 decimals): 1.005 * 1e18 exceeds Number.MAX_SAFE_INTEGER -> arbitrary trailing precision lost

1.005 * 1e9 evaluates to 1004999999.9999999, so Math.floor rounds it down. The on-chain amount ends up smaller than what the user typed. The magnitude is small for 9/6-decimal tokens (one smallest-unit), but for 18-decimal (wei) values any amount past ~15-16 significant digits loses precision because the product exceeds Number.MAX_SAFE_INTEGER.

Fix

Add a small parseTokenAmount(value, decimals) helper backed by viem's parseUnits (already a dependency), which parses the decimal string directly with no float intermediary, and use it at all six call sites:

  • bridge-sol, bridge-sol-with-bc, bridge-spl, bridge-wrapped-token
  • bridge-call (ETH -> wei)
  • spl/mint

The amount / value Zod fields now keep the validated string instead of transforming to a float, so the raw decimal input reaches parseUnits intact. Logging is unchanged (the field is still a string). parseUnits truncates over-precision inputs the same way the previous Math.floor did, so no input that worked before changes behavior.

Verification (local)

$ bun test src/internal/amount.test.ts
 3 pass  0 fail  7 expect() calls

The test documents the legacy float loss (1.005 @ 9 -> 1_004_999_999) and pins the corrected output (-> 1_005_000_000).

$ tsc --noEmit        # whole scripts package
 0 errors

Notes

  • This is a correctness fix; the per-transaction value difference is small for 9/6-decimal tokens, larger only for high-precision 18-decimal inputs.
  • CI does not currently run the TypeScript scripts package (the workflows cover the Forge and Solana programs), so the verification above was run locally. Happy to wire a bun test / tsc step into CI for scripts in a follow-up if useful.

The bridge and mint CLI handlers scaled the user-entered amount with
`BigInt(Math.floor(amount * 10 ** decimals))`, where `amount` is a
`parseFloat`-ed number. Evaluating `amount * 10 ** decimals` in IEEE-754
double precision loses accuracy: `1.005 * 1e9` is `1004999999.9999999`,
so `Math.floor` produces `1_004_999_999` lamports instead of the intended
`1_005_000_000`. `1.000001` at 6 decimals drops the trailing unit
(`1_000_000` instead of `1_000_001`), and 18-decimal (wei) amounts exceed
`Number.MAX_SAFE_INTEGER` and lose arbitrary trailing precision.

Add a small `parseTokenAmount(value, decimals)` helper backed by viem's
`parseUnits`, which parses the decimal string directly with no float
intermediary, and use it in all six call sites:

- bridge-sol, bridge-sol-with-bc, bridge-spl, bridge-wrapped-token (9 or
  mint-decimals)
- bridge-call (18, ETH -> wei)
- spl/mint (mint decimals)

The amount/value Zod fields now keep the validated string instead of
transforming to a float, so the raw decimal input reaches parseUnits
intact. Logging is unchanged (the field is still a string).

Verified locally:
- bun test src/internal/amount.test.ts : 3 pass (documents the legacy
  float loss and pins the corrected output)
- tsc --noEmit : 0 errors across the scripts package
@cb-heimdall

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants